iT邦幫忙

2024 iThome 鐵人賽

DAY 15
0
Software Development

DDD? Clean Architecture? Microservices? 帶你用.NET實作打造一個現代化微服務!系列 第 15

Day 15 - Account Service 實作:Authentication JWT 製作與認證

  • 分享至 

  • xImage
  •  

JWT 介紹

JWT (JSON Web Token) 是一種小型的「身份證」,用來讓不同的系統之間確認「你是誰」。它很常用在登入系統中。

JWT 的基本組成

  1. 頭部(Header):告訴系統這是什麼類型的令牌,以及用什麼方式加密。
  2. 負載(Payload):放使用者的資訊,例如使用者ID。
  3. 簽名(Signature):保護令牌不被篡改,確保資料安全。

JWT 如何運作

  1. 當你登入時,伺服器會給你一個 JWT,裡面有你的資訊。
  2. 你每次發出請求時都帶上這個 JWT。
  3. 伺服器收到 JWT 後,會驗證簽名來確認你是誰,並決定是否允許你訪問。

簡單來說,JWT 就是個「身份證」,幫助伺服器辨識你,避免每次查詢數據庫。

常見 Token 使用方式

https://ithelp.ithome.com.tw/upload/images/20240929/20168953nd7IbRpL3h.png

實作 JWT

先前我們 Account Service 實作了 Login 的方法,我們要把它當作一個 Authentication Server 來使用,所以每當 User 呼叫完 Login 後,在我們的 Response 內除了 User 的資訊之外,還會有一個 Token 的 Property,我們先前暫時使用 "token" 字串來代替,現在我們實作一個 JWTProvider 來產生這個 Token。

實作 ITokenProvider

我們先在 Account.Application 實作一個 ITokenProvider 介面,並有 GenerateTokenValidateToken 的方法。

namespace Account.Application;

public interface ITokenProvider
{
    string GenerateToken(Guid userId, string firstName, string lastName);
    string ValidateToken(string token);
}

然後我們 DI ITokenProviderAccountService 中,並且取代 "token" 字串。

using Account.Domain.Aggregates;

namespace Account.Application;

public class AccountService : IAccountService
{
    private readonly IUserRepository _userRepository;
    private readonly ITokenProvider _tokenProvider;
    
    public AccountService(IUserRepository userRepository, ITokenProvider tokenProvider)
    {
        _userRepository = userRepository;
        _tokenProvider = tokenProvider;
    }

    public AuthenticationResult Login(string email, string password)
    {
        var user = _userRepository.GetUserByEmail(email);

        if (user == null)
            throw new ArgumentException("Email address not exists");
        if (!user.VerifyPassword(password))
            throw new ArgumentException("Permission denied");

        return new AuthenticationResult(
            user.Id.Value,
            user.FirstName,
            user.LastName,
            user.Email,
            _tokenProvider.GenerateToken(user.Id.Value, user.FirstName, user.LastName)
        );
    }

    public AuthenticationResult Register(string firstName, string lastName, string email, string password)
    {
        if (_userRepository.GetUserByEmail(email) is not null)
            throw new ArgumentException("Email address already exists");
        
        var user = User.Create(firstName, lastName, email, password);

        _userRepository.Add(user);

        return new AuthenticationResult(
            user.Id.Value,
            user.FirstName,
            user.LastName,
            user.Email,
            _tokenProvider.GenerateToken(user.Id.Value, user.FirstName, user.LastName)
        );
    }
}

實作 JwtProvider

這個 JwtProvider 如何實現跟業務邏輯一點關係都沒有,屬於外部的技術實作,故我們放在 Infrastructure Layer。

我們在 Account.Infrastructure 建立 JwtProvider.cs 檔案,並使他繼承 ITokenProvider

using Account.Application;

namespace Account.Infrastructure;

public class JwtProvider : ITokenProvider
{
    public string GenerateToken(Guid userId, string firstName, string lastName)
    {
        throw new NotImplementedException();
    }

    public string ValidateToken(string token)
    {
        throw new NotImplementedException();
    }
}

實作 GenerateToken

微軟有提供很方便的工具來實作 JWT,直接安裝 Microsoft.IdentityModel.JsonWebTokens

using Account.Application;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;

namespace Account.Infrastructure;

public class JwtProvider : ITokenProvider
{
    public string GenerateToken(Guid userId, string firstName, string lastName)
    {
        var secret = GenerateHashSecret("my-secret");
        Console.WriteLine(secret);
        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
        var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new Claim(JwtRegisteredClaimNames.GivenName, firstName),
            new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        };

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Issuer = "todo-issuer",
            Audience = "todo-audience",
            Expires = DateTime.UtcNow.AddMinutes(5),
            Subject = new ClaimsIdentity(claims),
            SigningCredentials = signingCredentials
        };

        var tokenHandler = new JsonWebTokenHandler();

        return tokenHandler.CreateToken(tokenDescriptor);
    }

    public string ValidateToken(string token)
    {
        throw new NotImplementedException();
    }

    private string GenerateHashSecret(string input)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
        return Convert.ToBase64String(hash);
    }
}

然後註冊一下這個 JwtProvider

public static class AccountInfrastructureRegister
{
    public static IServiceCollection AddAccountInfrastructure(this IServiceCollection services)
    {
        services.AddScoped<IUserRepository, UserRepository>();
        services.AddSingleton<ITokenProvider, JwtProvider>();

        return services;
    }
}

接著再來註冊一個使用者測試看看

https://ithelp.ithome.com.tw/upload/images/20240929/20168953fLf3g1l2Yp.png

我們可以到 JWT.IO 來檢驗一下我們的 Token 是不是合法的

先把 Console 內的 Secret 貼到右下方的 your-256-bit-secret 中,接著就可以把 Response 內的 Token 貼到左方的 Token 中,如果下方顯示 Signature Verified 就代表這個 JWT 是合法用 Secret 做出來的。

https://ithelp.ithome.com.tw/upload/images/20240929/20168953amYqW4bT77.png

將設定檔放到 Config 內

通常我們不太會將參數值直接 Hardcode,這裡我們放到 appsettings

首先在 Account.Infrastructure 新增 JwtSettings.cs

namespace Account.Infrastructure;

public class JwtSettings
{
    public static string SectionName { get; } = "JwtSettings";
    public string Secret { get; init; } = null!;
    public string Issuer { get; init; } = null!;
    public string Audience { get; init; } = null!;
    public int ExpiryInMinutes { get; init; }
}

接著在 Account.Infrastructure 安裝 Configuration 工具 Microsoft.Extensions.ConfigurationMicrosoft.Extensions.Configuration.Binder

最後 DI IConfigurationJwtProvider 並實作

using Account.Application;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Configuration;

namespace Account.Infrastructure;

public class JwtProvider : ITokenProvider
{
    private readonly IConfiguration _configuration;
    private readonly JwtSettings _jwtSettings;

    public JwtProvider(IConfiguration configuration)
    {
        _configuration = configuration;
        _jwtSettings = _configuration.GetSection(JwtSettings.SectionName).Get<JwtSettings>() ?? throw new NullReferenceException();
    }

    public string GenerateToken(Guid userId, string firstName, string lastName)
    {
        var secret = GenerateHashSecret(_jwtSettings.Secret);
        Console.WriteLine(secret);
        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
        var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new Claim(JwtRegisteredClaimNames.GivenName, firstName),
            new Claim(JwtRegisteredClaimNames.FamilyName, lastName),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        };

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Issuer = _jwtSettings.Issuer,
            Audience = _jwtSettings.Audience,
            Expires = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpiryInMinutes),
            Subject = new ClaimsIdentity(claims),
            SigningCredentials = signingCredentials
        };

        var tokenHandler = new JsonWebTokenHandler();

        return tokenHandler.CreateToken(tokenDescriptor);
    }

    public string ValidateToken(string token)
    {
        throw new NotImplementedException();
    }

    private string GenerateHashSecret(string input)
    {
        var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input));
        return Convert.ToBase64String(hash);
    }
}

這樣就可以在 Account.Grpcappsettings.json 內把 JWT 設定的參數放進去

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Kestrel": {
    "EndpointDefaults": {
      "Protocols": "Http2"
    }
  },
  "JwtSettings": {
    "Secret": "my-secret",
    "ExpiryInMinutes": 5,
    "Issuer": "todo-issuer",
    "Audience": "todo-audience"
  }
}

實作 ValidateToken

產生了一個 JWT 就要能驗證真偽,所以現在來實作 ValidateToken 的方法。

這邊通常會把所有有關 Identity 的 Claims 都回傳,包含 Role 之類的內容,讓 Gateway 可以依此判定是否能使用此功能,但我這邊偷懶只回個 ID。

    public async Task<string?> ValidateTokenAsync(string token)
    {
        var secret = GenerateHashSecret(_jwtSettings.Secret);
        var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));

        var validationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = _jwtSettings.Issuer,

            ValidateAudience = true,
            ValidAudience = _jwtSettings.Audience,

            ValidateLifetime = true, 
            ClockSkew = TimeSpan.Zero,

            ValidateIssuerSigningKey = true,
            IssuerSigningKey = securityKey,
        };

        var tokenHandler = new JsonWebTokenHandler();

        var result = await tokenHandler.ValidateTokenAsync(token, validationParameters);

        return result.ClaimsIdentity?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
    }

因為這裡有非同步的作法,記得 ITokenProvider 也要改

namespace Account.Application;

public interface ITokenProvider
{
    string GenerateToken(Guid userId, string firstName, string lastName);
    Task<string?> ValidateTokenAsync(string token);
}

把這個方法 Expose 出去,先在 AccountService 新增 ValidateToken

namespace Account.Application;

public interface IAccountService
{
    AuthenticationResult Register(string FirstName, string lastName, string email, string password);
    AuthenticationResult Login(string email, string password);
    Task<Guid> ValidateTokenAsync(string token);
}

public class AccountService : IAccountService
{
    // ...

    public async Task<Guid> ValidateTokenAsync(string token)
    {
        var guid = await _tokenProvider.ValidateTokenAsync(token);

        Guid.TryParse(guid, out Guid result);

        return result;
    }
}

增加 Proto Endpoint

syntax = "proto3";

option csharp_namespace = "Account.Grpc";

service AccountGrpcService {
  rpc Register (RegisterRequest) returns (AuthenticationResponse);
  rpc Login (LoginRequest) returns (AuthenticationResponse);
  rpc ValidateToken(ValidateTokenRequest) returns (ValidateTokenResponse);
}

message RegisterRequest {
  string FirstName = 1;
  string LastName = 2;
  string Email = 3;
  string Password = 4;
}

message LoginRequest {
  string Email = 1;
  string Password = 2;
}

message AuthenticationResponse {
  string Id = 1;
  string FirstName = 2;
  string LastName = 3;
  string Email = 4;
  string Token = 5;
}

message ValidateTokenRequest {
  string Token = 1;
}

message ValidateTokenResponse {
  bool isValid = 1;
  string UserId = 2;
}

Override ValidateToken

    public override async Task<ValidateTokenResponse> ValidateToken(ValidateTokenRequest request, ServerCallContext context)
    {
        var result = await _accountService.ValidateTokenAsync(request.Token);

        return new ValidateTokenResponse() { IsValid = result != Guid.Empty, UserId = result.ToString() };
    }

測試一下

https://ithelp.ithome.com.tw/upload/images/20240929/20168953TzIYwhbVsF.png

結語

這篇實在太長了,許多細節直接呈現在程式碼內,希望能讓大家更了解 JWT,未來在實作 BFF Gateway 的時候會再更進一步介紹 Authorization 和 Authentication 的不同。

最後的架構圖

https://ithelp.ithome.com.tw/upload/images/20240929/20168953Y4lvzRYXoR.png


上一篇
Day 14 - Account Service 實作:Login 使用者登入
下一篇
Day 16 - Account Infrastructure 實作:資料庫建置與 ORM 開發
系列文
DDD? Clean Architecture? Microservices? 帶你用.NET實作打造一個現代化微服務!30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言